Skip to content

fix: handle PEP 604 unions (X | Y) like Union[X, Y] - #746

Merged
tlambert03 merged 3 commits into
mainfrom
fix/pep604-unions
Aug 26, 2026
Merged

fix: handle PEP 604 unions (X | Y) like Union[X, Y]#746
tlambert03 merged 3 commits into
mainfrom
fix/pep604-unions

Conversation

@tlambert03

@tlambert03 tlambert03 commented Aug 26, 2026

Copy link
Copy Markdown
Member

Follow-up to #745, which uncovered this.

The bug

get_origin reports a different origin for the two union spellings on python < 3.14:

get_origin(Union[int, None]) is Union   # True
get_origin(int | None)       is Union   # False on 3.11-3.13, True on 3.14

magicgui compared get_origin(...) is Union in four places, so every PEP 604 annotation quietly took the non-union path. Two user-visible consequences:

@magicgui
def f(x: Optional[int] = None): ...   # f.x.annotation -> int          ✅
@magicgui
def f(x: int | None = None): ...      # f.x.annotation -> int | None   ❌
register_type(int | str, return_callback=cb)        # registered nothing for int or str
register_type(Union[int, str], return_callback=cb)  # registers both  ✅

Widget selection was unaffected — _split_annotation_type and _literal_choices use get_args() directly, which works for both spellings. Only the four is Union comparisons were wrong, which is why this stayed hidden. Masked entirely on 3.14, where types.UnionType became typing.Union.

The fix

Adds magicgui._util.is_union, which accepts both spellings, and uses it at all four sites (_type_map, _ui_field ×2, _value_widget).

Re-enabling the pyupgrade rewrite

#745 put UP007/UP045 in ruff's ignore list because of the bug above. With it fixed, UP045 is safe and is now enabled — but scoped deliberately:

  • UP045 on for src/ and docs/, off for tests/. Tests exercise both spellings on purpose; blanket-rewriting them would cost coverage (this is what neutered test_no_order in ci(pre-commit.ci): autoupdate #728).
  • UP007 stays off. Union is still required as a runtime value for the public type aliases (PathLike, ChoicesType, AppRef, TableData, WidgetRef) and for Union[args] construction. ruff offers no fix for those 11 sites — one of them, WidgetClass, holds string forward refs where | would be a runtime TypeError — so enabling it would only leave permanent lint errors.

The rewrite itself is small: 3 files, all genuine annotation positions. docs/examples/demo_widgets/optional.py now uses str | None, which means test_examples.py exercises the fix end to end.

Also included: a lint fix for main

main is currently failing ruff. #742 added from typing import Callable to the ipynb backend and merged after #745 switched target-version to py311, so its CI ran against the old config and never saw UP035.

Related issues

I searched the tracker; none of these report this bug, and this PR does not close any of them. Listing them because they're adjacent and were candidates:

Testing

Two regression tests in tests/test_types.py, both confirmed failing before the fix and passing after. Full suite green on 3.11 / 3.13 / 3.14 across PyQt6, PyQt5, PySide6 (439 passed).

🤖 Generated with Claude Code

tlambert03 and others added 2 commits August 26, 2026 14:47
`get_origin(int | None)` is `types.UnionType`, not `typing.Union`, on
python < 3.14 -- so the four `get_origin(...) is Union` comparisons
silently took the non-union path for PEP 604 annotations.

Most visibly, the Optional wrapper was not stripped from a widget's
reported annotation:

    @magicgui
    def f(x: Optional[int] = None): ...   # .annotation -> int
    @magicgui
    def f(x: int | None = None): ...      # .annotation -> int | None

and `register_type(int | str, return_callback=...)` registered nothing
for the individual member types.

Adds `magicgui._util.is_union`, which accepts both spellings, and uses
it at all four sites.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
UP035 under the new py311 target. #742 merged after #745 switched
target-version, so its CI ran against the old config and main is
currently failing ruff.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@codecov

codecov Bot commented Aug 26, 2026

Copy link
Copy Markdown

Codecov Report

✅ All modified and coverable lines are covered by tests.
✅ Project coverage is 89.52%. Comparing base (965de0e) to head (b222042).

Additional details and impacted files
@@            Coverage Diff             @@
##             main     #746      +/-   ##
==========================================
+ Coverage   89.49%   89.52%   +0.03%     
==========================================
  Files          40       40              
  Lines        4893     4899       +6     
==========================================
+ Hits         4379     4386       +7     
+ Misses        514      513       -1     

☔ View full report in Codecov by Harness.
📢 Have feedback on the report? Share it here.

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.

The preceding fix makes `X | None` behave like `Optional[X]`, so the
pyupgrade rewrite is safe for src. Scoped deliberately:

- UP045 is enabled for src/ and docs/ only; tests/ keep it ignored,
  since they exercise both spellings on purpose (see test_no_order).
- UP007 stays ignored: `Union` is still needed as a runtime *value* for
  the public type aliases (PathLike, ChoicesType, AppRef, TableData,
  WidgetRef) and for `Union[args]` construction. ruff offers no fix for
  those 11 sites, so enabling it would just leave permanent errors.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@tlambert03
tlambert03 merged commit 355bbe4 into main Aug 26, 2026
31 checks passed
@tlambert03
tlambert03 deleted the fix/pep604-unions branch August 26, 2026 13:23
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant